Oura integration - Fixing Time anchoring, Sleep session from the ring, Framing / decode robustness, Activity (Tier-B, deliberately not persisted)#143
Open
pipiche38 wants to merge 25 commits into
Conversation
The 0x43 debug spill wasn't documented, so I added it in three linked spots:
- §2.4 (reassembly) — a dated "byte-alignment desync → phantom tags" note: how a run of 0x43 debug records surfaced as a phantom 0x4C "sleep_summary" because 0x4C=L/0x49=I/0x53=S are legal ASCII chars that alias real tags, plus the defensive resync rule (reject len<4, resynchronise on implausible type/len, never let a mid-stream byte mint a Tier-B summary).
- §6.15 (0x43) — expanded with the actual decoded strings ("Sw to App", "in_bed=0", "…in_info=6", "bc 0x43"), the TLV layout, and the aliasing caution.
- §6.12 (sleep summaries) — a "beware phantom sightings" warning so nobody chases an ASCII 0x4C as a real fixture.
Gave .debugText its own ingest case (it previously fell into default and was dropped). It now logs each non-empty, trimmed diagnostic string with its ring counter: Oura: debug (0x43) rt=60470 "in_bed=0" Diagnostics only — never persisted or scored. As a bonus, decoding these here also means a debug string can no longer silently alias a real event tag on that code path.
…wo mechanisms: 1. Drop-prefix list for the known high-rate firmware chatter: DHR_mode, DHR data sub, DHR_state, DHR_info, ← ~150ms HR state-machine spam PPG_cont, S:, E:, AFs, blestda, nr, BQ ← per-beat PPG windows / AGC / BLE state 1. Case-sensitive on purpose: the uppercase S:/E: PPG markers are dropped, while the lowercase s:/e: sleep boundaries survive. 2. Consecutive-duplicate suppression — collapses runs of identical lines (e.g. the DHR_info:neg t ×20 burst) via a lastDebugText guard.
The .streaming block now hands the ring the current UTC (OuraCommands.syncTime) BEFORE draining history, so it emits a usable 0x42 time-sync event (§5.5). Without this every fetched record stayed "[no anchor yet]" and last-night sleep / skin-temp could not be placed on a calendar day (Sleep screen read matched=0). SyncTime writes the ring clock, exactly as the official Oura app does on every connect. Sent once per session, before the first fetch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OuraReassembler.feed only validated len >= 4, never the type byte. Once byte-misaligned inside a 0x43 debug-text payload, ASCII letters that alias real tags (p=0x70, I=0x49, L=0x4C, Q=0x51) minted a PHANTOM "summary" whose bogus len swallowed the following real records (e.g. the sleep-phase timeline). Now a record only starts on a plausible type - a known OuraEventTag or the 0x11/0x0D outer responses that round-trip - else drop one byte and re-scan. Adds isPlausibleRecordStart + 3 FramingTests; realigns instead of stalling. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reassembler-misaligned phantom landing on a known spo2 tag byte decoded to impossible values and got persisted (integration build: 159 spo2Sample rows, -320..12,856,474), violating the honest-data invariant on a ring whose SpO2 feature 0x04 is gated OFF. OuraStreamMapping now persists an .spo2 sample only when its value is in the plausible oxygen-saturation band [85,100], aligned with open_oura tools/run_spo2.py (r-ratio calibration clamped to [85,100]) and Oura's own reporting floor. Values outside are DROPPED, never clamped - clamping garbage to "100%" would fabricate an oxygen reading. Only 0x6F yields direct percentages (~95-96, s6.5); 0x7B (unpinned uint16) and 0x77 dc_raw (PPG waveform) are not percentages, and nothing downstream reads spo2 red (AnalyticsEngine nulls spo2Pct), so this loses no real signal and yields zero rows on the gated-off ring. Mirrored in the Kotlin twin for parity. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…§6.12.1) Investigated open_oura docs/algorithms/sleepnet.md. The Oura app's 4-stage hypnogram is NOT on the BLE wire: it is SleepNet, an on-PHONE PyTorch model (per-30s epochs) shipped AES-256-GCM-encrypted with a server-delivered, cloud-cached key — the one Oura metric not reproducible without Oura's cloud. Documents the consequence for NOOP's honest-data invariant: we do not chase SleepNet. A NOOP sleep session is built from signals that cross the BLE boundary — the ring's Tier-A 2-bit 0x4E/0x5A phase codes (OURA_SLEEP_PHASE), its 0x49/0x76 boundaries and 0x43 debug in_bed/s:/e: narration, plus NOOP's own staging over raw HR/HRV/temp/motion — never relabelled as "Oura sleep stages". Adds a [sleepnet] citation key.
A framing desync mints a record with a garbage ring timestamp that, at 100 ms/tick, converts to a date days-to-years from the anchor yet still inside the loose 2020-2035 epoch gate (rt~1.9e9 -> +6yr/2033; rt~16.7M -> +19 days). On live Gen 3 this scattered ~25% of a night's skin-temp rows across 2020-2034, so they never landed on the correct calendar day - and the same phantoms carried the garbage SpO2 values. OuraDriver.unixSeconds now rejects any conversion more than +1 day after or -90 days before the session anchor (history is always recent-past relative to the 0x42 anchor). Returns nil so the caller drops/parks the sample instead of banking a mis-dated row (honest-data invariant) - the single chokepoint every history sample passes. Mirrored in the Kotlin twin; +4 tests (Swift+Kotlin), OURA_PROTOCOL.md 5.5 documents the guard. 83 OuraProtocol tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AnalyticsEngine derives sleep from the gravity-driven SleepStager, and an Oura ring streams no accelerometer - so Sleep + Skin Temp stayed blank even with a night of correctly-dated OURA_SLEEP_PHASE events. NOOP now builds its OWN session from the ring's coarse phase codes (the polished hypnogram is SleepNet, cloud-gated, not on the wire - OURA_PROTOCOL.md 6.12.1). - OuraSleepSessionBuilder (pure): (ts, 2-bit stage) -> [SleepSession] with merged StageSegments + efficiency; splits on gaps, drops sub-minimum/all-wake runs. - analyzeDay gains providedSleepSessions: when set it REPLACES the gravity detector, so the ring's night flows through the same funnels (sleep totals + skin-temp window). nil default keeps every WHOOP caller byte-identical. - 11 tests incl. an end-to-end analyzeDay-with-no-gravity proof. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deep-dived Th0rgal/open_oura (Rust workspace RE'd from libringeventparser.so + the Android app) and folded the findings in as section 9 (leads to fixture-validate, per the doc's trust rule): - canonical connect->sync sequence + the "no RData 0x03 on a normal pull" rule; - authoritative opcode / 0x2F sub-op set; cursor = deciseconds; legacy vs extended; - feature-gating mechanism (server flags default false client-side) explaining the SpO2 / real_steps OFF defaults; - undocumented event tags (0x62 respiration spot, 0x8B spo2_r_pi, 0x71/0x6E IBI, 0x59/0x56/0x6C/0x74/0x84/0x86, 0x87/0x88 backend-gated, 0x80 gen conflict); - RData raw sampler (0x03) + the raw-accelerometer lead; batched timestamping; - reference SpO2 quadratic + skin-temp algorithm. Flags two actions in section 8: the SyncTime (0x12) send-layout mismatch and the skinTempFunnel 300-sample floor vs open_oura's ~120. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ded a live body-weight provider from the profile down to the estimator, matching the codebase's existing closure-injection style:
AppModel.profile.weightKg
→ SourceCoordinator(bodyweightKg:) [new param]
→ OuraLiveSource(bodyweightKg:) [new param]
→ OuraActivityEstimator.summarize(bodyweightKg: bodyweightKg())
- Read live (a closure, not a captured value), so if you edit your weight in Settings it takes effect on the next flush.
- Removed the activityBodyweightKg = 75.0 constant entirely.
- Default fallback is 70 kg (only used if profile weight is unset/≤0) — deliberately matching the production calorie path's own 70 kg fallback, so there's one consistent default across the app rather than introducing a second (75).
The old syncTime built a 24-bit floor(unix/256) coarse time (~4.3-min resolution) with a bogus 0xF6 trailer copied from the 0x85 RTC-beacon. This did not match the native client's req_sync_time(secs, 0) and could set the ring clock wrongly / be silently ignored (masked only by the ring's own RTC still yielding a ~correct 0x42 anchor). Now emit `12 09 <unix_secs: u64 LE (8 B)> <tz: i8 half-hours>`, tz=0 (UTC) as the reference client sends; NOOP buckets local days downstream. Mirrored byte-for-byte in the Kotlin twin. Tests pin the byte layout and the signed tz byte on both sides. Doc §5.4/§8/§9.2 updated to FIXED.
An Oura ring streams no accelerometer, so the gravity-driven SleepStager returns nothing for its nights: the Sleep card read blank AND the skin-temp funnel window (gated to a sleepSession) never opened. IntelligenceEngine now extracts the OURA_SLEEP_PHASE events from the night window's events (already read for the off-wrist backstop, no extra store read), builds sessions via OuraSleepSessionBuilder, and passes them to analyzeDay as providedSleepSessions. When non-nil these replace the gravity detector's output, so the ring's night flows through the SAME funnels the WHOOP path uses (sleep totals, skin-temp window, rest). Any non-Oura owner emits no such events, so the gravity stager stays byte-identical for every existing device. The built sessions land in res.cachedSleep, which the existing pipeline already persists. Kotlin twin: added providedSleepSessions to AnalyticsEngine.analyzeDay, the new OuraSleepSessionBuilder, and the IntelligenceEngine wiring (parsing `phase` from EventRow.payloadJSON). StrandAnalytics 964 tests pass, incl. the builder suite.
…lock The phantom guard (unixSeconds anchor-relative window) already rejected a misframed record's garbage ring timestamp on the ingest path. But the pending-anchor DRAIN collapsed the two reasons unixSeconds returns nil -- "no anchor yet" and "anchor present but rt is phantom" -- into a single '?? now` fallback. So a misframed skin-temp/SpO2/phase parked before the anchor landed was stamped at wall-clock and BANKED, slipping past the guard and polluting "today" with a bogus sample (honest-data violation). Expose driver.hasAnchor so the drain can tell them apart: anchor present + rt rejected -> DROP (and log the count); no anchor this session -> keep the honest wall-clock fallback so a real last-night record is placed at ~arrival rather than silently lost.
# Conflicts: # Strand/BLE/OuraLiveSource.swift
…p source)
The decoded OURA_SLEEP_PHASE (0x4E) events turn out to be sparse bursts
the ring emits only at connection time, not a continuous overnight
timeline -- a real 8 h night persisted ~30 phase events clustered at the
sync moments, none during sleep, so a session built from them
under-counts (9.2 h read as ~5 h).
The ring's firmware instead narrates its OWN sleep decision as 0x43
debug text: `check_sleep`, then `s: <rt>` (bedtime) / `e: <rt>` (wake),
where <rt> is a ring timestamp in the 0x42 anchor domain -- so
unixSeconds(rt) converts each straight to UTC. OuraCheckSleepParser
(pure, both platforms) extracts the window and OuraLiveSource logs the
anchored bedtime->wake span. Validated on device: last night read
23:46 -> 07:43 (7h 56m) vs the wearer's actual 23:49 -> 07:41.
A lone `e:` can arrive with no fresh `s:`, pairing the new wake against
the previous night's stale start -- observed live as a phantom 33 h
window. An 18 h max-window guard rejects it (honest-data).
Also confirmed and documented (OURA_PROTOCOL.md 6.15): the Tier-B
sleep_summary tags (0x49/4B/4C/57/58 = ASCII I/K/L/W/X) captured so far
are framing-desync aliases of debug text ("DHR_state:2", "batt: 100"),
not real summaries -- do not decode until a clean binary fixture.
INVESTIGATION prototype: the window is LOGGED, not yet persisted as a
sleepSession.
Promote the §6.15 check_sleep prototype to a real sleepSession. The
ring's own bedtime->wake decision (validated on device against imported
WHOOP reference: 476 min / 23:46->07:43 vs WHOOP 461-472 min /
23:49->07:41 -- within ~4 min) is the reliable duration source where the
sparse OURA_SLEEP_PHASE bursts under-count.
- OuraLiveSource persists an OURA_SLEEP_WINDOW event (ts=bedtime,
payload {start,end}) on a stable window; DO NOTHING upsert => one row
per night.
- OuraSleepSessionBuilder.session(fromWindowStart:end:) builds one
session: a single stage-UNKNOWN "asleep" segment, efficiency 1.0.
- hypnogramMetrics counts TST as any non-wake time -- byte-identical for
every gravity/phase session (light|deep|rem are the only non-wake
labels those stagers emit), and it lets the "asleep" window contribute
its full duration to total sleep WITHOUT inventing deep/REM/light
(those stay 0). Honest: real duration, blank stages.
- IntelligenceEngine reads OURA_SLEEP_WINDOW and PREFERS it over the
phase-event builder (gravity stager for non-Oura owners).
Kotlin twin throughout. StrandAnalytics 973 / WhoopStore 234 tests pass.
NOTE: surfacing is still gated -- the ring streams no overnight HR so
IntelligenceEngine's hr>=200 night gate skips Oura nights; addressed
next.
The persisted OURA_SLEEP_WINDOW never reached a card: an Oura ring banks no overnight HR (disconnected while you sleep), and two HR-only gates blocked it. Fix both — but rank a bare window BELOW any full record, so the ring surfaces only nights nothing richer owns. 1. Day ownership. resolveDayOwner marked hasData from HR presence alone, so the active ring (priority 0) lost every night to an imported WHOOP strap (priority 2). It now also counts a persisted OURA_SLEEP_WINDOW — BUT as new DayOwnerResolver.Candidate.richData=false. A richer record (richData=true: HR-derived stages/recovery/HRV) wins before device priority, so a WHOOP import keeps a day it fully recorded (same duration, but stages+recovery preserved) and the ring's bare window only surfaces a day nothing richer owns. richData defaults true, so every legacy candidate collapses to the old priority-only order — existing days are byte-identical. 2. Scoring gate. The loop skipped any night with < 200 HR samples. It now also scores a night with an OURA_SLEEP_WINDOW (duration honest from the window; HR-derived fields stay null) — reached only on days the ring actually owns per (1). New WhoopStore.hasEvent(deviceId:kind:from:to:) — a LIMIT-1 EXISTS probe (no payload decode) drives both. Kotlin twin throughout (DayOwnerResolver richData, WhoopDao.hasEvent + repo, IntelligenceEngine). WhoopStore 235 / StrandAnalytics 976 tests pass (+3 resolver richness cases). Validation: last connection logged the ring's Jul 7->8 check_sleep window at 9h12m = 552 min, matching the imported WHOOP night's totalSleepMin=552 to the minute. That night WHOOP (richer) keeps the day; Jul 8->9 (7h56m, no WHOOP import) the ring owns and surfaces.
…ume cursor The GetEvents (0x11) response was being misread: bytes 2-5 are bytes_left (a remaining-BYTE count that drains ~800KB→0), not a "last ring timestamp cursor". Persisting that byte-count and comparing it across sessions minted a phantom "ring-time regression" every connect → reset-to-0 → full history re-dump (~40k phantom-record drops + ryanbr#91 log spam). Fix, matching open_oura's model (EventBatchSummary + nextEventToSync): - parseGetEventsResponse now returns (eventsReceived, bytesLeft, moreData); the drain loops while bytes_left > 0, and the resume position is CLIENT-managed (max stored ring-time), not read from the 0x11 body. - OuraLiveSource persists a real ring-time resume cursor, only when it resolves against the anchor; a plausibility ceiling (maxPlausibleResumeTicks = 500M ds) clamps any absurd persisted value back to a full pull so a pre-fix garbage cursor can't wedge the ring on a seek-past-end forever. - describeCursor renders the cursor as a human-readable date (all history / date / out-of-window / no-anchor). Proven on-device (Oura Ring Gen3): connect 1 clamps the leftover garbage cursor, does one full pull, acquires the anchor, and stores resume cursor 1624998 [2026-07-09 16:25:42]; connect 2 resumes from it and returns 55 events with bytes_left=0 — zero phantom drops, no re-dump. Android twin + docs/OURA_PROTOCOL.md §5.2/§5.3 updated to match. OuraProtocol tests 99/0.
The anchor-set paths (0x42 time-sync, 0x85 RTC beacon) validated only the epoch (2020-2035). A framing desync can hand an anchor record a plausible epoch paired with a GARBAGE ring-time in the billions (the 4 rt bytes read off a wrong offset). Anchoring on that pins anchorRingTime ~2e9 ticks from every real sample, so the anchor-relative phantom guard then drops ALL genuine history — the ring's data starves even though the epoch looked fine. Add isPlausibleAnchorRingTime (<= 500M ticks ≈ 579 days uptime, mirroring OuraLiveSource.maxPlausibleResumeTicks) and require BOTH halves before seating an anchor, in both drivers. OuraLiveSource now mirrors the full gate so "UTC time anchor acquired" never logs on a sync the driver rejected, and names which half failed (epoch vs ring-time). Tests: OuraProtocol 102/0 (+3 Swift: ring-time bounds, garbage-rt time-sync, garbage-rt beacon; mirrored in the Kotlin twin). docs/OURA_PROTOCOL.md §5.5 documents the two-half anchor-set gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # Strand/Data/IntelligenceEngine.swift
Author
|
Just confirm the iOS Build Succeeded ! |
This was referenced Jul 9, 2026
…finite loop) The GetEvents 0x11 drain re-dumps the ring's entire log from boot on every cursor-0 continuation, so when bytesLeft plateaus instead of reaching 0 the drain looped forever. Bound it three ways: a full-pull-pass fast-exit (stop after one complete pass once maxStoredRingTime resolves against the anchor), a stall guard (bytesLeft not advancing by drainProgressEpsilon), and a wall-clock deadline. On any forced stop we commit maxStoredRingTime if it resolves and otherwise HOLD the cursor — never reset to 0, which was what minted the re-dump. Verified on-device: stops after 2 reads, clean resume.
A misframed 0x42/0x85 beacon carrying a wrong-year epoch (e.g. 2021) passed
the static 15-year plausibility window and seated a bad ring→UTC anchor,
mis-stamping whole sample batches into 2021 and starving recent history.
Add acceptsAnchorEpoch(): still requires the 2020–2035 floor, but when a
near-now reference is supplied the epoch must fall within maxAnchorDrift
(7 days) of it. OuraLiveSource seeds the reference at driver-creation time.
Falls back to the static window when no reference is set (test/replay).
Verified on-device: 2021-dated beacons now rejected ("not within ±7d of
now"); last night's samples stamp correctly. Kotlin twin kept in parity.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Summary
Brings the Oura Ring Gen3 into NOOP as a first-class, honest-data source over its native BLE
protocol — live HR/IBI, HRV, SpO2, skin temperature, and a ring-native sleep session — while keeping
the Tier-A/Tier-B trust invariant and byte-for-byte Swift↔Kotlin parity. The headline fix (#91)
stops a newly-paired ring from re-dumping its entire banked history on every connect.
Merged up to
main@ v8.5.2; the only conflict (IntelligenceEngine.swift) was a cleanboth-sides-added resolution (the Oura sleep bridge alongside main's #141 HRV nightly trace).
What this adds
Time anchoring (the core of honest Oura data)
SyncTime (0x12)on connect (authoritative u64-LE-seconds + tz) to unlock the ring→UTC anchor.0x42time-sync (primary) /0x85RTC beacon (secondary), gated to aplausible 2020–2035 epoch and a plausible boot-relative ring-time (≤500M ticks). Neither half
alone is enough — a misframed record can carry a good epoch with a garbage ring-time.
anchor is dropped, never banked at a wrong wall-clock time.
#91 — the full re-dump loop (headline)
GetEvents (0x11)response was misread: bytes 2–5 arebytes_left(a remaining-BYTE count thatdrains ~800 KB→0), not a "last ring-timestamp cursor". Persisting that byte-count and comparing it
across sessions minted a phantom "ring-time regression" every connect → reset-to-0 → a full re-dump
(~40 k phantom drops + log spam).
bytes_left > 0; the resume position is client-managed(max stored ring-time), only persisted once it resolves against the anchor. A plausibility ceiling
clamps any absurd persisted cursor back to a full pull, so a pre-fix garbage cursor can't wedge the
ring on a seek-past-end forever. Cursor renders as a human-readable date in logs.
return only new events with
bytes_left=0— zero phantom drops, no re-dump.Sleep (ring-native, honest)
SleepSessionfrom the ring's owncheck_sleepwindow (s:/e:firmware bedtime→wake,validated 7 h 56 m vs a wearer's ~7 h 52 m) — preferred over the sparse connection-time
OURA_SLEEP_PHASEbursts, which under-count. Stage-unknown but honest total sleep; nothing faked.providedSleepSessions), so the ring's nightopens the skin-temp window and feeds rest — without hiding a richer WHOOP import for the same day.
Framing / decode robustness
.debugText (0x43)gets its own ingest case (was silently dropped): logs each diagnostic string, andas a bonus a debug string can no longer alias a real event tag on that path. Gated against high-rate
firmware chatter with a drop-prefix list + consecutive-duplicate suppression.
#87 Activity (Tier-B, deliberately not persisted)
— logged as a PROXY, never turned into stored steps (MET ≠ steps; honest-data invariant).
Docs
docs/OURA_PROTOCOL.md: digest of the open_oura crates/tools (§9), the two-half anchor-set gate (§5.5),the
bytes_leftcorrection (§5.2/§5.3),check_sleepas the honest sleep source (§6.15), and theSleepNet-is-cloud-locked note (§6.12.1).
Testing
All new decode/anchor/framing behavior is unit-tested in the pure packages; the Android twin mirrors
every fixture byte-for-byte.
Cross-platform parity
Every protocol/mapping change lands on both the Swift package and the Kotlin twin (
android/), withidentical test fixtures —
OuraDriver,Framing,OuraLiveSource, the sleep bridge, and the anchorgate all match across platforms.
Reserves / caveats (known limitations, read before merge)
(102/978/235), and the merge-conflict resolution is structurally verified, but a full
xcodebuild -scheme NOOPiOSand the Gradle build are the last gate not run here. Build locallybefore merging.
check_sleeppersistence is a PROTOTYPE. The window is decoded, anchored, logged, and bridgedinto analytics, but promoting it to a durable
sleepSessionrow is still stabilizing — on a daywhere a richer WHOOP import exists, the import intentionally still wins ("richer record wins").
A pure-Oura day is the case that surfaces the ring's own night.
dump-borne (it rides inside the history stream), so a quiet reconnect with little new history may
carry no anchor; the cursor then stays put and re-pulls the same small sliver next time rather than
advancing past un-banked data. Self-healing: a larger sync eventually carries an anchor and the cursor
jumps forward. It cannot regress to a 40 k full dump (that only happened from cursor 0).
0x7E/0x7F real_steps) isserver-flag-gated OFF on the ring, so the Steps card staying empty is expected; the MET-derived
figure is logged as a proxy, never stored.
0x42seconds-not-ms unit correction and the anchor windows should be treated as Gen3-validated until a
second ring/generation confirms them.
a known false positive (the app target can't resolve SPM modules outside Xcode), not a real error.
Type of change
How it was tested
Test environment is MacOs. I'm rebuilding the all application and running it. Comparing Oura ring Gen 3 collected data against Whoop/MG (connected to Whoop App)
Checklist
swift testinPackages/<name>)android/(./gradlew testFullDebugUnitTest)StrandDesigntokens — no hardcoded colors, fonts, or spacingdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues